# -*- coding: utf-8 -*-
"""
MobSF 自动化扫描脚本（支持 APK 批量上传、扫描、报告导出）
作者：张晟瑀
版本历史：
  ver1.0    实现MobSF接口调用，四大组件暴露风险导出，基础功能参数：hpduk                               by 张晟瑀 20251014
  ver1.1    mobReport函数和四大组件功能解耦、四大组件策略不再只筛选 "rule": "explicitly_exported"      by 张晟瑀 20251015
  ver1.2    实现webView结果导出，从mob导出code_analysis中的相关字段                                   by 张晟瑀 20251016
  ver1.2.1  参数处理部分重构，使用函数单独处理参数；导出部分优化                                        by 张晟瑀 20251017
  ver1.2.2  AI帮写了注释，优化了一些变量名和一些弱智小冗余代码                                         by 张晟瑀 20251020
  ver1.3    实现疑似敏感硬编码导出，对应s参数，敏感信息初步的筛选清单secretList                         by 张晟瑀 20251021
  ver1.3.1  添加了-h参数的一个命令示例，只保留中文帮助                                                 by 张晟瑀 20251021
  ver1.4    实现证书信息扫描和导出，对应c参数，函数内维护字段名                                         by 张晟瑀 20251107
"""

import requests
import os
import json     # 用于处理 JSON 数据
from requests_toolbelt.multipart.encoder import MultipartEncoder  # 支持 multipart/form-data 上传
import pandas as pd  # 用于生成 Excel 报告
import sys, getopt  # 用于解析命令行参数

# 可选：测试时使用的代理（默认注释掉）
# testProxies = {
#     "http": "http://127.0.0.1:8083",
#     "https": "https://127.0.0.1:8083"
# }

# 需要排查的敏感字段列表。在 reportSecrets() 函数中，如果导出字段包含secretList列表中的关键字，则在报告中添加一个字段用于报告筛查。
secretList = [
    "pass",
    "pwd",
    "http://",
    "https://"
]

def mobOptions(argv):
    """
    解析命令行参数，并返回配置字典。
    
    参数:
        argv: sys.argv[1:]，即命令行参数列表
    
    返回:
        dict: 包含所有配置选项的字典
    """
    # 默认配置
    optionsDict = {
        "mobsfUrl": "http://localhost:8000",      # MobSF 服务地址
        "mobsfAPIKey": "",                        # MobSF API 密钥（必须提供）
        "hasAPIKey": False,                       # 是否提供了 API Key
        "openPdfReporting": False,                # 是否生成 PDF 报告
        "openAutoDeleteTask": False,              # 是否扫描后自动删除任务
        "openJsonDebug": False,                   # 是否保存原始 JSON 报告用于调试
        "openWebView": False,                     # 是否导出 WebView 相关风险
        "openManifest": False,                    # 是否导出 Manifest 相关风险（四大组件、调试、备份等）
        "openSecrets": False,                     # 是否导出 Secrets 相关风险
        "openCertInfo": False                     # 是否导出证书信息
    }

    try:
        # 定义支持的短选项和长选项
        # -h: 帮助
        # -p: PDF 报告
        # -d: 自动删除任务
        # -m: Manifest 扫描报告（四大组件等）
        # -w: WebView 扫描报告
        # -u: MobSF URL
        # -k: API Key
        # -s: Secrets
        # -c: Certificate Info
        # --json-debug: 保存原始 JSON
        opts, args = getopt.getopt(argv, "hcpdmwsu:k:", ["json-debug"])
    except getopt.GetoptError:
        print("Usage: python mobScript.py -k <apikey> [-u <http://localhost:8000>] [-d] [-p] [-h] [-m] [-w] [-s] [-c]")
        sys.exit(2)

    # 解析每个选项
    for opt, arg in opts:
        if opt == "-h":
            # 打印帮助信息
            print("此脚本必须配合MobSF使用，请确保与mobSF网络连通。")

            print("必须将该脚本置于与所有被测apk包同一目录下。\n" \
                  "用法: python mobScript.py -k <apikey> [-u <http://localhost:8000>] [-d] [-p] [-h] [-m] [-w] [-s]\n" \
                  "参数:\n" \
                  "-k\tApiKey of mobSF. 登录mobSF后点击API获取ApiKey。\n" \
                  "-u\tUrl of mobSF. 默认: http://localhost:8000\n" \
                  "-d\t扫描完成后自动删除mobSF上的测试任务。\n" \
                  "-p\t输出PDF报告至当前目录下pdf_report目录。\n" \
                  "-m\t输出四大组件、调试和备份扫描结果表格。\n" \
                  "-w\t输出WebView扫描结果表格。\n" \
                  "-s\t输出疑似硬编码密文字符串表格。\n"\
                  "-c\t输出证书信息表格。")
            
            print("例如：\n" \
                  "> python mobScript.py -k <your_api_key> -m -w -s")
            sys.exit(2)
        elif opt == "-u":
            optionsDict['mobsfUrl'] = arg
        elif opt == "-p":
            optionsDict['openPdfReporting'] = True
        elif opt == "-d":
            optionsDict['openAutoDeleteTask'] = True
        elif opt == "-m":
            optionsDict['openManifest'] = True            
        elif opt == "-w":
            optionsDict['openWebView'] = True
        elif opt == "-s":
            optionsDict['openSecrets'] = True
        elif opt == "-k":
            optionsDict['mobsfAPIKey'] = arg
            optionsDict['hasAPIKey'] = True
        elif opt == "-c":
            optionsDict['openCertInfo'] = True
        elif opt == "--json-debug":
            optionsDict['openJsonDebug'] = True

    # 检查是否提供了 API Key（必需）
    if not optionsDict['hasAPIKey']:
        print("\"-k <apikey>\" must be provided.")
        sys.exit(2)

    return optionsDict


def convert_path(absolute_path):
    """
    从绝对路径中提取不带扩展名的文件名（用于报告命名）。
    
    示例: "/path/to/app.apk" -> "app"
    """
    file_name_with_ext = os.path.basename(absolute_path)  # 获取文件名（含扩展名）
    file_name_without_ext, _ = os.path.splitext(file_name_with_ext)  # 去除扩展名
    return file_name_without_ext


def mobUpload(fileName, mobOptDict):
    """
    上传 APK 文件到 MobSF。
    
    参数:
        fileName: 不带 .apk 的文件名（如 "app"）
        mobOptDict: 配置字典
    
    返回:
        requests.Response: 上传响应对象
    """
    try: 
        print(f"---Uploading {fileName}.apk------")
        apkName = fileName + ".apk"
        # 构造 multipart/form-data 请求体
        multipart_data = MultipartEncoder(
            fields={'file': (apkName, open(apkName, 'rb'), 'application/octet-stream')}
        )
        uploadHeaders = {
            'Content-Type': multipart_data.content_type,
            'X-Mobsf-Api-Key': mobOptDict['mobsfAPIKey']
        }
        uploadResponse = requests.post(
            mobOptDict['mobsfUrl'] + "/api/v1/upload",
            data=multipart_data,
            headers=uploadHeaders
            # proxies=testProxies  # 如需调试可取消注释
        )
        print("---Uploaded------")
        return uploadResponse
    except Exception as e:
        print("---Upload Error------")
        print(e)
        return None


def mobScan(uploadResponse, mobOptDict):
    """
    触发 MobSF 对已上传 APK 的扫描。
    
    参数:
        uploadResponse: 上传返回的响应（需包含 hash）
        mobOptDict: 配置字典
    """
    try: 
        print("---Scanning------")
        scanHeaders = {'X-Mobsf-Api-Key': mobOptDict['mobsfAPIKey']}
        scanHash = {'hash': uploadResponse.json()['hash']}
        scanResponse = requests.post(
            mobOptDict['mobsfUrl'] + "/api/v1/scan",
            data=scanHash,
            headers=scanHeaders
            # proxies=testProxies
        )
        print("---Scan Accomplished------")
    except Exception as e:
        print("---Scan Error------")
        print(e)


def mobPDF(uploadResponse, fileName, mobOptDict):
    """
    下载并保存 PDF 报告（如果启用）。
    
    参数:
        uploadResponse: 上传响应（含 hash）
        fileName: APK 文件名（不含扩展名）
        mobOptDict: 配置字典
    """
    if mobOptDict['openPdfReporting']:
        try:
            print("---Saving PDF------")
            # 创建 pdf_report 目录（如不存在）
            if not os.path.exists("pdf_report"):
                os.mkdir("pdf_report")
            pdfName = fileName + ".pdf"  
            pdfHeaders = {'X-Mobsf-Api-Key': mobOptDict['mobsfAPIKey']}
            pdfHash = {'hash': uploadResponse.json()['hash']}
            pdfResponse = requests.post(
                mobOptDict['mobsfUrl'] + "/api/v1/download_pdf",
                data=pdfHash,
                headers=pdfHeaders,
                stream=True
                # proxies=testProxies
            )
            # 流式写入 PDF 文件
            with open(pdfName, 'wb') as flip:
                for chunk in pdfResponse.iter_content(chunk_size=1024):
                    if chunk:
                        flip.write(chunk)
            # 移动到 pdf_report 目录
            try:
                os.rename(pdfName, os.path.join("pdf_report", pdfName))
                print(f"---Saved as ./pdf_report/{pdfName}------")
            except Exception as e:
                print("---PDF Rename Error------")
                print(e)
        except Exception as e:
            print("---PDF Error------")
            print(e)


def mobReport(uploadResponse, mobOptDict):
    """
    获取 JSON 格式的完整扫描报告。
    
    返回:
        dict: MobSF 返回的 JSON 报告数据
    """
    try:
        print("---Exporting Report------")
        reportHeaders = {'X-Mobsf-Api-Key': mobOptDict['mobsfAPIKey']}
        reportHash = {'hash': uploadResponse.json()['hash']}
        reportResponse = requests.post(
            mobOptDict['mobsfUrl'] + "/api/v1/report_json",
            data=reportHash,
            headers=reportHeaders
            # proxies=testProxies
        )
        reportData = reportResponse.json()
        return reportData
    except Exception as e:
        print("---JSON REPORT Error------")
        print(e)
        return {}


def mobDelete(uploadResponse, mobOptDict):
    """
    删除 MobSF 上的扫描任务（如果启用）。
    """
    if mobOptDict['openAutoDeleteTask']:
        try:
            deleteHeaders = {'X-Mobsf-Api-Key': mobOptDict['mobsfAPIKey']}
            deleteHash = {'hash': uploadResponse.json()['hash']}
            deleteResponse = requests.post(
                mobOptDict['mobsfUrl'] + "/api/v1/delete_scan", 
                data=deleteHash,
                headers=deleteHeaders
                # proxies=testProxies
            )
        except Exception as e:
            print("---DELETE TASK Error------")
            print(e)


def reportFourComp(fileName, json_data):
    """
    从 Manifest 分析结果中提取“四大组件暴露”相关风险。
    筛选条件：rule 字段包含 "exported" 且 severity 为 "warning" 或 "high"
    
    返回:
        list[dict]: 过滤后的风险项列表
    """
    if "manifest_analysis" in json_data and isinstance(json_data["manifest_analysis"].get("manifest_findings"), list):
        filteredData = []
        manifestFindings = json_data["manifest_analysis"]["manifest_findings"]
        for result in manifestFindings:
            if "exported" in result['rule'] and result['severity'] in ("warning", "high"):
                filteredDict = {'APKName': fileName}
                filteredDict.update({
                    'Title': result['title'],
                    'Severity': result['severity']
                })
                # 尝试提取组件类型、名称、权限（结构可能不一致，需容错）
                try:
                    comp = result['component']
                    filteredDict.update({
                        'ComponentType': comp[0],
                        'ComponentName': comp[1],
                        'ComponentPermission': comp[2] if len(comp) > 2 else ''
                    })
                except (IndexError, TypeError):
                    filteredDict.update({'ComponentPermission': ''})
                filteredData.append(filteredDict)
        print(f"---{fileName} Exported Components Reported------")
        return filteredData
    else:
        print("---No Manifest Info------")
        return []


def reportDbgBkp(fileName, json_data):
    """
    提取 Manifest 中的调试（debuggable）和备份（allowBackup）风险。
    
    返回:
        list[dict]: 调试/备份风险列表
    """
    if "manifest_analysis" in json_data and isinstance(json_data["manifest_analysis"].get("manifest_findings"), list):
        filteredData = []
        manifestFindings = json_data["manifest_analysis"]["manifest_findings"]
        for result in manifestFindings:
            if result['rule'] in ("app_is_debuggable", "app_allowbackup"):
                filteredDict = {
                    'APKName': fileName,
                    'Title': result['title'],
                    'Severity': result['severity']
                }
                filteredData.append(filteredDict)
        print(f"---{fileName} Debug and Backup Permission Reported---")
        return filteredData
    else:
        print("---No Manifest Info------")
        return []


def reportWebView(fileName, json_data):
    """
    从代码分析结果中提取 WebView 相关风险。
    MobSF 的 code_analysis.findings 中，key 为规则名，若包含 "webview" 则视为相关。
    
    返回:
        list[dict]: WebView 风险详情列表
    """
    if "code_analysis" in json_data and "findings" in json_data["code_analysis"]:
        filteredData = []
        codeFindings = json_data["code_analysis"]["findings"]
        for rule_name, rule_info in codeFindings.items():
            if "webview" in rule_name.lower():  # 更健壮的匹配（忽略大小写）
                for targetFile, positions in rule_info.get("files", {}).items():
                    filteredDict = {
                        'APKName': fileName,
                        'Rule': rule_name,
                        'Severity': rule_info["metadata"]["severity"],
                        'CodeFile': targetFile,
                        'CodePosition': positions  # 可能是行号列表
                    }
                    filteredData.append(filteredDict)
        return filteredData
    else:
        print("---No Code Info----------")
        return []


def reportSecrets(fileName, json_data):
    """
    提取代码分析结果中的Possible Hardcode Secrets字段。

    返回：
        list[dict]: Secrets 结果列表
    """
    if "secrets" in json_data and isinstance(json_data["secrets"], list):
        filteredData = []
        for sec in json_data["secrets"]:
            filteredDict = {
                'APKName': fileName,
                'PossibleHardcodeSecret': sec
            }
            for keyword in secretList:
                if keyword in sec.lower():
                    filteredDict.update({'Keyword': keyword})
                    break
                else:
                    filteredDict.update({'Keyword': ''})

            filteredData.append(filteredDict)
        return filteredData
    else:
        print("---No Secret Info---------")
        return [] 


def reportCertInfo(fileName, json_data):
    """
    提取代码分析结果中的certificate_info字段

    返回：list[Dict]: CertInfo 结果列表
    """
    reportInfo = [
        'v1 signature',
        'v2 signature',
        'v3 signature',
        'v4 signature',
        'X.509 Subject',
        'Hash Algorithm',
        'PublicKey Algorithm',
        'Bit Size'
    ]

    if "certificate_analysis" in json_data:
        filteredData = []
        certInfoStr = json_data['certificate_analysis']['certificate_info']
        certInfoSplited = certInfoStr.split('\n')
        filteredDict = {'APKName': fileName}
        for sub in reportInfo:
            for info in range(len(certInfoSplited)):
                if certInfoSplited[info].startswith(sub):
                    filteredDict.update({sub: certInfoSplited[info].split(':')[1]})
        filteredData.append(filteredDict)

        return filteredData

    print(certInfoSplited)


def report2xlsx(fData, xReport):
    """
    将新数据追加到现有的 pandas DataFrame 中。
    
    参数:
        fData: 新数据（list of dict）
        xReport: 现有 DataFrame
    
    返回:
        pd.DataFrame: 合并后的新 DataFrame
    """
    if not fData:  # 如果无数据，直接返回原 DataFrame
        return xReport
    df = pd.DataFrame(fData)
    xReport = pd.concat([xReport, df], ignore_index=True)
    return xReport


def main(argv):
    """
    主函数：遍历当前目录下所有 APK，依次上传、扫描、导出报告。
    """
    # 初始化空的 DataFrame 用于汇总报告
    xlsxReport = pd.DataFrame()   # Manifest 相关报告
    wxlsxReport = pd.DataFrame()  # WebView 相关报告
    sxlsxReport = pd.DataFrame()  # Secrets 相关报告
    cxlsxReport = pd.DataFrame()  # Certificate 相关报告

    # 解析命令行参数
    optDict = mobOptions(argv)

    # 获取当前工作目录（脚本应与 APK 同目录）
    testDirectory = os.getcwd()
    print(f"Scanning directory: {testDirectory}")

    # 遍历目录中的所有 .apk 文件
    for file in os.listdir(testDirectory):
        if file.endswith('.apk'):
            # 提取不带路径和扩展名的文件名（用于报告命名）
            fileName = convert_path(os.path.join(testDirectory, file))
            print(f"\nProcessing APK: {file}")

            # 1. 上传 APK
            resp = mobUpload(fileName, optDict)
            if resp is None or resp.status_code != 200:
                print(f"---Failed to upload {file}, skipping...---")
                continue

            # 2. 触发扫描
            mobScan(resp, optDict)

            # 3. 生成 PDF（如启用）
            mobPDF(resp, fileName, optDict)

            # 4. 获取完整 JSON 报告
            reportJson = mobReport(resp, optDict)
            if not reportJson:
                print(f"---Empty report for {file}, skipping analysis...---")
                continue

            # 5. 导出 Manifest 相关风险（如启用）
            if optDict['openManifest']:
                xlsxReport = report2xlsx(reportDbgBkp(fileName, reportJson), xlsxReport)
                xlsxReport = report2xlsx(reportFourComp(fileName, reportJson), xlsxReport)

            # 6. 导出 WebView 风险（如启用）
            if optDict['openWebView']:
                wxlsxReport = report2xlsx(reportWebView(fileName, reportJson), wxlsxReport)

            # 7. 导出 Secret 风险（如启用）
            if optDict['openSecrets']:
                sxlsxReport = report2xlsx(reportSecrets(fileName, reportJson), sxlsxReport)
                
            # 8. 导出 Certificate 信息

            if optDict['openCertInfo']:
                cxlsxReport = report2xlsx(reportCertInfo(fileName, reportJson), cxlsxReport)
            
            # 9. 保存原始 JSON（调试用）
            if optDict['openJsonDebug']:
                try: 
                    with open(fileName + '.json', 'w', encoding='utf-8') as f:
                        json.dump(reportJson, f, ensure_ascii=False, indent=4)
                    print(f"---Saved debug JSON: {fileName}.json---")
                except Exception as e:
                    print("---Json Saving Error------")
                    print(e)

            # 10. 删除任务（如启用）
            mobDelete(resp, optDict)

    # 最终保存 Excel 报告
    if optDict['openManifest']:
        xlsxReport.to_excel('组件扫描报告.xlsx', index=False)
        print("\n---组件扫描报告已保存为 '组件扫描报告.xlsx'-----")
    if optDict['openWebView']:
        wxlsxReport.to_excel('WebView扫描报告.xlsx', index=False)
        print("\n---WebView扫描报告已保存为 'WebView扫描报告.xlsx'---")
    if optDict['openSecrets']:
        sxlsxReport.to_excel('Secrets扫描报告.xlsx', index=False)
        print("\n---Secrets扫描报告已保存为 'Secrets扫描报告.xlsx'---")
    if optDict['openCertInfo']:
        cxlsxReport.to_excel('证书扫描报告.xlsx', index=False)
        print("\n---证书扫描报告已保存为 '证书扫描报告.xlsx'---")

if __name__ == "__main__":
    main(sys.argv[1:])